Module modules.resources

File name: resources.py Author: Martin Jůda Python Version: 3.7 Description: Module with REST API resources

Expand source code
"""
    File name: resources.py
    Author: Martin Jůda
    Python Version: 3.7
    Description: Module with REST API resources
"""

import base64
import random
import re
from datetime import datetime

from flask_httpauth import HTTPBasicAuth
from flask_restful import Resource, reqparse, abort, HTTPException
from sqlalchemy.exc import OperationalError

from modules.constants import (
    DEFAULT_NUMBER_OF_QUESTIONS,
    CREDENTIALS,
    MAX_NUMBER_OF_QUESTIONS,
    DEFAULT_PERMISSION,
)
from modules.db import Database
from modules.errors import NoActiveStudyError, StagUnavailableError
from modules.logger import logger
from modules.stag import StagCommunicator


def handle_stag_unavailable_error():
    """Raise HTTP 503 - Internal server error"""
    abort(503, message="Request to STAG API failed.")


def internal_server_error():
    """Raise HTTP 500 - Internal server error"""
    abort(500, message="Internal server error.")


auth = HTTPBasicAuth()


@auth.verify_password
def verify(username, password):
    """Verify incoming request credentials

    Args:
        username (str): user's login
        password (str): user's password

    Returns:
        bool: True if user and password are ok otherwise False
    """
    if not (username and password):
        return False
    return CREDENTIALS.get(username) == password


class Question(Resource):
    @auth.login_required
    def get(self):
        """GET /questions HTTP method"""
        try:
            args = self._validate_request_params()
            db_session = None
            try:
                db = Database()
                db_session = db.get_db_session()
            except OperationalError:
                logger.exception(msg="Problem with connection to database")
                self._close_db_session(db_session)
                abort(500, message="Can not connect to database.")
            device = db.device_by_id(db_session, args["device_id"])
            if not device:
                abort(400, message="This device id is not registered in database")
            # Get assigned study programmes to a device
            device_permissions = [d.study_code for d in device.study_programmes]
            db.store_access_log(db_session, args["card_number"], args["device_id"])
            stag = StagCommunicator()
            try:
                studies = stag.get_active_studies(card_number=args["card_number"])
                study_codes = stag.get_study_codes_for_active_studies(studies)
                self.evaluate_user_device_permission(
                    reference_codes=device_permissions, study_codes=study_codes
                )
                # Get the subjects for the first active study
                completed_subjects = stag.get_completed_subjects_for_study(
                    study=studies[0], actual_year=datetime.today().year
                )
            except NoActiveStudyError:
                logger.info(f"No active study for card_number: {args['card_number']}")
                self._close_db_session(db_session)
                abort(
                    404, message="No active study found.", error_code="NO_ACTIVE_STUDY"
                )
            except StagUnavailableError:
                logger.exception(msg="Can not connect establish connection to STAG.")
                self._close_db_session(db_session)
                handle_stag_unavailable_error()
            number_of_questions = (  # set requested number of questions
                args.get("question_count")
                if args.get("question_count")
                else DEFAULT_NUMBER_OF_QUESTIONS
            )
            subjects = self._choose_subjects(completed_subjects, number_of_questions)
            questions = db.select_questions(
                db_session=db_session,
                subjects=subjects,
                requested_number_of_questions=number_of_questions,
            )
            questions = [self._question_to_dict(q) for q in questions]
            self._close_db_session(db_session)
            return {
                "questions": questions,
                "student_name": self._student_name(studies[0]),
            }
        except HTTPException:
            raise
        except Exception as err:
            logger.exception(err)
            internal_server_error()

    def _validate_request_params(self):
        """HTTP params parser and validator

        Returns:
            dict: Incoming request parameters

        Raises:
            HTTPException: HTTP 400 bad requests on invalid params
        """
        arg_parser = reqparse.RequestParser()
        arg_parser.add_argument(
            "card_number",
            type=str,
            required=True,
            location="args",
        )
        arg_parser.add_argument("device_id", type=int, required=True, location="args")
        arg_parser.add_argument(
            "question_count", type=int, required=False, location="args"
        )
        args = arg_parser.parse_args(strict=True)
        self._validate_card_number(card_number=args["card_number"])
        self._validate_device_id(device_id=args["device_id"])
        self._validate_questions_count(question_count=args.get("question_count"))
        return args

    def evaluate_user_device_permission(self, reference_codes, study_codes):
        """Evaluate if user has access to this device

        Args:
            reference_codes list(str): list of allowed codes
            study_codes list(str): list of student codes

        Raises:
            HTTPException: HTTP 403 Forbidden - access not allowed
        """
        if not self._is_study_code_in_reference(study_codes, reference_codes):
            abort(403, message="This student does not have access to this device")

    @staticmethod
    def _is_study_code_in_reference(study_codes, reference_codes):
        """Test if some of student codes is in reference

        Args:
            reference_codes list(str): list of allowed codes
            study_codes list(str): list of student codes

        Returns:
            True if reference contains some student code otherwise False
        """
        if DEFAULT_PERMISSION in reference_codes:
            return True
        for study_code in study_codes:
            if study_code in reference_codes:
                return True
        return False

    @staticmethod
    def _student_name(study_info):
        """Format student name based on info from Stag

        Args:
            study_info (dict): Info about student from Stag

        Returns:
            str: student's name
        """
        if study_info["titulPred"]:
            name = f"{study_info['titulPred']} "
        else:
            name = ""
        name += f"{(study_info['jmeno']).upper()} {(study_info['prijmeni']).upper()}"
        if study_info["titulZa"]:
            name += f", {study_info['titulZa']}"
        return name

    @staticmethod
    def _close_db_session(db_session):
        """Close database connection"""
        if db_session:
            db_session.close()

    @staticmethod
    def _choose_subjects(completed_subjects, number_of_questions):
        """Choose random subjects from completed study

        Args:
            completed_subjects (list): student's absolved subjects
            number_of_questions (int): number of subjects to choose

        Returns:
            list: choose subjects
        """
        if not completed_subjects:
            return []
        if len(completed_subjects) < number_of_questions:
            return completed_subjects
        return random.sample(completed_subjects, number_of_questions)

    @staticmethod
    def _validate_card_number(card_number):
        """Test if card number matches expected format

        Args:
            card_number (str): Student's card number

        Raises:
            HTTPException: HTTP 400 Bad request on invalid card number
        """
        if not re.match(r"^[A-Z0-9]{8}$", card_number):
            abort(400, message="Invalid card_number format.")

    @staticmethod
    def _validate_questions_count(question_count):
        """Test if requested number of questions is in pre-set range

        Args:
            question_count (int): number of questions requested by client

        Raises:
                HTTPException: HTTP 400 Bad request on invalid number
        """
        if question_count is not None:
            if question_count <= 0 or question_count > MAX_NUMBER_OF_QUESTIONS:
                abort(
                    400,
                    message=f"Invalid question_count. Value must be in "
                    f"range 1-{MAX_NUMBER_OF_QUESTIONS}.",
                )

    @staticmethod
    def _validate_device_id(device_id):
        """Test if device id is not negative

        Args:
            device_id (int): id of device

        Raises:
                HTTPException: HTTP 400 Bad request on invalid number
        """
        if device_id < 1:
            abort(400, message=f"Invalid device_id. Value must be positive.")

    @staticmethod
    def _question_to_dict(question_entity):
        """Transform question to output format

        Args:
            question_entity: DB question class entity

        Returns:
            dict: Response (json) format of question
        """
        return {
            "question": question_entity.question,
            "department_shortcut": question_entity.department_shortcut,
            "subject_name": question_entity.subject.name,
            "right_answer": question_entity.right_answer,
            "wrong_answer_1": question_entity.wrong_answer_1,
            "wrong_answer_2": question_entity.wrong_answer_2,
            "wrong_answer_3": question_entity.wrong_answer_3,
            "picture_id": question_entity.id if question_entity.picture else None,
        }


class Image(Resource):
    @auth.login_required
    def get(self, image_id):
        """GET /images/<image_id> HTTP method

        Args:
            image_id (int): ID reference of question's picture

        """
        try:
            db_session = None
            try:
                db = Database()
                db_session = db.get_db_session()
            except OperationalError:
                logger.exception(msg="Problem with connection to database")
                self._close_db_session(db_session)
                abort(500, message="Can not connect to database.")
            try:
                image = db.find_question_image(db_session, image_id)
            except LookupError:
                self._close_db_session(db_session)
                abort(404, message="Invalid image ID.")
            if not image.picture:
                abort(404, message="This question does not have image.")
            self._close_db_session(db_session)
            return {"image": (base64.b64encode(image.picture)).decode("utf-8")}
        except HTTPException:
            raise
        except Exception as err:
            logger.exception(err)
            raise internal_server_error()

    @staticmethod
    def _close_db_session(db_session):
        """Close database connection"""
        if db_session:
            db_session.close()

Functions

def handle_stag_unavailable_error()

Raise HTTP 503 - Internal server error

Expand source code
def handle_stag_unavailable_error():
    """Raise HTTP 503 - Internal server error"""
    abort(503, message="Request to STAG API failed.")
def internal_server_error()

Raise HTTP 500 - Internal server error

Expand source code
def internal_server_error():
    """Raise HTTP 500 - Internal server error"""
    abort(500, message="Internal server error.")
def verify(username, password)

Verify incoming request credentials

Args

username : str
user's login
password : str
user's password

Returns

bool
True if user and password are ok otherwise False
Expand source code
@auth.verify_password
def verify(username, password):
    """Verify incoming request credentials

    Args:
        username (str): user's login
        password (str): user's password

    Returns:
        bool: True if user and password are ok otherwise False
    """
    if not (username and password):
        return False
    return CREDENTIALS.get(username) == password

Classes

class Image

Represents an abstract RESTful resource. Concrete resources should extend from this class and expose methods for each supported HTTP method. If a resource is invoked with an unsupported HTTP method, the API will return a response with status 405 Method Not Allowed. Otherwise the appropriate method is called and passed all arguments from the url rule used when adding the resource to an Api instance. See :meth:~flask_restful.Api.add_resource for details.

Expand source code
class Image(Resource):
    @auth.login_required
    def get(self, image_id):
        """GET /images/<image_id> HTTP method

        Args:
            image_id (int): ID reference of question's picture

        """
        try:
            db_session = None
            try:
                db = Database()
                db_session = db.get_db_session()
            except OperationalError:
                logger.exception(msg="Problem with connection to database")
                self._close_db_session(db_session)
                abort(500, message="Can not connect to database.")
            try:
                image = db.find_question_image(db_session, image_id)
            except LookupError:
                self._close_db_session(db_session)
                abort(404, message="Invalid image ID.")
            if not image.picture:
                abort(404, message="This question does not have image.")
            self._close_db_session(db_session)
            return {"image": (base64.b64encode(image.picture)).decode("utf-8")}
        except HTTPException:
            raise
        except Exception as err:
            logger.exception(err)
            raise internal_server_error()

    @staticmethod
    def _close_db_session(db_session):
        """Close database connection"""
        if db_session:
            db_session.close()

Ancestors

  • flask_restful.Resource
  • flask.views.MethodView
  • flask.views.View

Class variables

var methods

Methods

def get(self, image_id)

GET /images/ HTTP method

Args

image_id : int
ID reference of question's picture
Expand source code
@auth.login_required
def get(self, image_id):
    """GET /images/<image_id> HTTP method

    Args:
        image_id (int): ID reference of question's picture

    """
    try:
        db_session = None
        try:
            db = Database()
            db_session = db.get_db_session()
        except OperationalError:
            logger.exception(msg="Problem with connection to database")
            self._close_db_session(db_session)
            abort(500, message="Can not connect to database.")
        try:
            image = db.find_question_image(db_session, image_id)
        except LookupError:
            self._close_db_session(db_session)
            abort(404, message="Invalid image ID.")
        if not image.picture:
            abort(404, message="This question does not have image.")
        self._close_db_session(db_session)
        return {"image": (base64.b64encode(image.picture)).decode("utf-8")}
    except HTTPException:
        raise
    except Exception as err:
        logger.exception(err)
        raise internal_server_error()
class Question

Represents an abstract RESTful resource. Concrete resources should extend from this class and expose methods for each supported HTTP method. If a resource is invoked with an unsupported HTTP method, the API will return a response with status 405 Method Not Allowed. Otherwise the appropriate method is called and passed all arguments from the url rule used when adding the resource to an Api instance. See :meth:~flask_restful.Api.add_resource for details.

Expand source code
class Question(Resource):
    @auth.login_required
    def get(self):
        """GET /questions HTTP method"""
        try:
            args = self._validate_request_params()
            db_session = None
            try:
                db = Database()
                db_session = db.get_db_session()
            except OperationalError:
                logger.exception(msg="Problem with connection to database")
                self._close_db_session(db_session)
                abort(500, message="Can not connect to database.")
            device = db.device_by_id(db_session, args["device_id"])
            if not device:
                abort(400, message="This device id is not registered in database")
            # Get assigned study programmes to a device
            device_permissions = [d.study_code for d in device.study_programmes]
            db.store_access_log(db_session, args["card_number"], args["device_id"])
            stag = StagCommunicator()
            try:
                studies = stag.get_active_studies(card_number=args["card_number"])
                study_codes = stag.get_study_codes_for_active_studies(studies)
                self.evaluate_user_device_permission(
                    reference_codes=device_permissions, study_codes=study_codes
                )
                # Get the subjects for the first active study
                completed_subjects = stag.get_completed_subjects_for_study(
                    study=studies[0], actual_year=datetime.today().year
                )
            except NoActiveStudyError:
                logger.info(f"No active study for card_number: {args['card_number']}")
                self._close_db_session(db_session)
                abort(
                    404, message="No active study found.", error_code="NO_ACTIVE_STUDY"
                )
            except StagUnavailableError:
                logger.exception(msg="Can not connect establish connection to STAG.")
                self._close_db_session(db_session)
                handle_stag_unavailable_error()
            number_of_questions = (  # set requested number of questions
                args.get("question_count")
                if args.get("question_count")
                else DEFAULT_NUMBER_OF_QUESTIONS
            )
            subjects = self._choose_subjects(completed_subjects, number_of_questions)
            questions = db.select_questions(
                db_session=db_session,
                subjects=subjects,
                requested_number_of_questions=number_of_questions,
            )
            questions = [self._question_to_dict(q) for q in questions]
            self._close_db_session(db_session)
            return {
                "questions": questions,
                "student_name": self._student_name(studies[0]),
            }
        except HTTPException:
            raise
        except Exception as err:
            logger.exception(err)
            internal_server_error()

    def _validate_request_params(self):
        """HTTP params parser and validator

        Returns:
            dict: Incoming request parameters

        Raises:
            HTTPException: HTTP 400 bad requests on invalid params
        """
        arg_parser = reqparse.RequestParser()
        arg_parser.add_argument(
            "card_number",
            type=str,
            required=True,
            location="args",
        )
        arg_parser.add_argument("device_id", type=int, required=True, location="args")
        arg_parser.add_argument(
            "question_count", type=int, required=False, location="args"
        )
        args = arg_parser.parse_args(strict=True)
        self._validate_card_number(card_number=args["card_number"])
        self._validate_device_id(device_id=args["device_id"])
        self._validate_questions_count(question_count=args.get("question_count"))
        return args

    def evaluate_user_device_permission(self, reference_codes, study_codes):
        """Evaluate if user has access to this device

        Args:
            reference_codes list(str): list of allowed codes
            study_codes list(str): list of student codes

        Raises:
            HTTPException: HTTP 403 Forbidden - access not allowed
        """
        if not self._is_study_code_in_reference(study_codes, reference_codes):
            abort(403, message="This student does not have access to this device")

    @staticmethod
    def _is_study_code_in_reference(study_codes, reference_codes):
        """Test if some of student codes is in reference

        Args:
            reference_codes list(str): list of allowed codes
            study_codes list(str): list of student codes

        Returns:
            True if reference contains some student code otherwise False
        """
        if DEFAULT_PERMISSION in reference_codes:
            return True
        for study_code in study_codes:
            if study_code in reference_codes:
                return True
        return False

    @staticmethod
    def _student_name(study_info):
        """Format student name based on info from Stag

        Args:
            study_info (dict): Info about student from Stag

        Returns:
            str: student's name
        """
        if study_info["titulPred"]:
            name = f"{study_info['titulPred']} "
        else:
            name = ""
        name += f"{(study_info['jmeno']).upper()} {(study_info['prijmeni']).upper()}"
        if study_info["titulZa"]:
            name += f", {study_info['titulZa']}"
        return name

    @staticmethod
    def _close_db_session(db_session):
        """Close database connection"""
        if db_session:
            db_session.close()

    @staticmethod
    def _choose_subjects(completed_subjects, number_of_questions):
        """Choose random subjects from completed study

        Args:
            completed_subjects (list): student's absolved subjects
            number_of_questions (int): number of subjects to choose

        Returns:
            list: choose subjects
        """
        if not completed_subjects:
            return []
        if len(completed_subjects) < number_of_questions:
            return completed_subjects
        return random.sample(completed_subjects, number_of_questions)

    @staticmethod
    def _validate_card_number(card_number):
        """Test if card number matches expected format

        Args:
            card_number (str): Student's card number

        Raises:
            HTTPException: HTTP 400 Bad request on invalid card number
        """
        if not re.match(r"^[A-Z0-9]{8}$", card_number):
            abort(400, message="Invalid card_number format.")

    @staticmethod
    def _validate_questions_count(question_count):
        """Test if requested number of questions is in pre-set range

        Args:
            question_count (int): number of questions requested by client

        Raises:
                HTTPException: HTTP 400 Bad request on invalid number
        """
        if question_count is not None:
            if question_count <= 0 or question_count > MAX_NUMBER_OF_QUESTIONS:
                abort(
                    400,
                    message=f"Invalid question_count. Value must be in "
                    f"range 1-{MAX_NUMBER_OF_QUESTIONS}.",
                )

    @staticmethod
    def _validate_device_id(device_id):
        """Test if device id is not negative

        Args:
            device_id (int): id of device

        Raises:
                HTTPException: HTTP 400 Bad request on invalid number
        """
        if device_id < 1:
            abort(400, message=f"Invalid device_id. Value must be positive.")

    @staticmethod
    def _question_to_dict(question_entity):
        """Transform question to output format

        Args:
            question_entity: DB question class entity

        Returns:
            dict: Response (json) format of question
        """
        return {
            "question": question_entity.question,
            "department_shortcut": question_entity.department_shortcut,
            "subject_name": question_entity.subject.name,
            "right_answer": question_entity.right_answer,
            "wrong_answer_1": question_entity.wrong_answer_1,
            "wrong_answer_2": question_entity.wrong_answer_2,
            "wrong_answer_3": question_entity.wrong_answer_3,
            "picture_id": question_entity.id if question_entity.picture else None,
        }

Ancestors

  • flask_restful.Resource
  • flask.views.MethodView
  • flask.views.View

Class variables

var methods

Methods

def evaluate_user_device_permission(self, reference_codes, study_codes)

Evaluate if user has access to this device

Args

reference_codes list(str): list of allowed codes study_codes list(str): list of student codes

Raises

HTTPException
HTTP 403 Forbidden - access not allowed
Expand source code
def evaluate_user_device_permission(self, reference_codes, study_codes):
    """Evaluate if user has access to this device

    Args:
        reference_codes list(str): list of allowed codes
        study_codes list(str): list of student codes

    Raises:
        HTTPException: HTTP 403 Forbidden - access not allowed
    """
    if not self._is_study_code_in_reference(study_codes, reference_codes):
        abort(403, message="This student does not have access to this device")
def get(self)

GET /questions HTTP method

Expand source code
@auth.login_required
def get(self):
    """GET /questions HTTP method"""
    try:
        args = self._validate_request_params()
        db_session = None
        try:
            db = Database()
            db_session = db.get_db_session()
        except OperationalError:
            logger.exception(msg="Problem with connection to database")
            self._close_db_session(db_session)
            abort(500, message="Can not connect to database.")
        device = db.device_by_id(db_session, args["device_id"])
        if not device:
            abort(400, message="This device id is not registered in database")
        # Get assigned study programmes to a device
        device_permissions = [d.study_code for d in device.study_programmes]
        db.store_access_log(db_session, args["card_number"], args["device_id"])
        stag = StagCommunicator()
        try:
            studies = stag.get_active_studies(card_number=args["card_number"])
            study_codes = stag.get_study_codes_for_active_studies(studies)
            self.evaluate_user_device_permission(
                reference_codes=device_permissions, study_codes=study_codes
            )
            # Get the subjects for the first active study
            completed_subjects = stag.get_completed_subjects_for_study(
                study=studies[0], actual_year=datetime.today().year
            )
        except NoActiveStudyError:
            logger.info(f"No active study for card_number: {args['card_number']}")
            self._close_db_session(db_session)
            abort(
                404, message="No active study found.", error_code="NO_ACTIVE_STUDY"
            )
        except StagUnavailableError:
            logger.exception(msg="Can not connect establish connection to STAG.")
            self._close_db_session(db_session)
            handle_stag_unavailable_error()
        number_of_questions = (  # set requested number of questions
            args.get("question_count")
            if args.get("question_count")
            else DEFAULT_NUMBER_OF_QUESTIONS
        )
        subjects = self._choose_subjects(completed_subjects, number_of_questions)
        questions = db.select_questions(
            db_session=db_session,
            subjects=subjects,
            requested_number_of_questions=number_of_questions,
        )
        questions = [self._question_to_dict(q) for q in questions]
        self._close_db_session(db_session)
        return {
            "questions": questions,
            "student_name": self._student_name(studies[0]),
        }
    except HTTPException:
        raise
    except Exception as err:
        logger.exception(err)
        internal_server_error()